Skip to content

feat: rtk rewrite — single source of truth for LLM hook rewrites#241

Open
FlorianBruniaux wants to merge 10 commits intomasterfrom
feat/rtk-rewrite
Open

feat: rtk rewrite — single source of truth for LLM hook rewrites#241
FlorianBruniaux wants to merge 10 commits intomasterfrom
feat/rtk-rewrite

Conversation

@FlorianBruniaux
Copy link
Collaborator

@FlorianBruniaux FlorianBruniaux commented Feb 21, 2026

Problem

Every LLM hook (Claude Code, Gemini CLI, future Copilot...) was duplicating the same command-mapping logic in bash. Adding a new RTK filter meant updating every hook independently.

Thomas Delalonde identified this exactly:

"toute la partie pour créer la variable REWRITTEN pourrait être encapsulée dans RTK, afin de faciliter sa mise à jour avec de nouvelles commandes au besoin, ça réduit drastiquement le rtk_rewrite, et permet de le concentrer uniquement sur la partie proxy avec le modèle"

He was right. We had 3 independent copies of the same mapping:

  • .claude/hooks/rtk-rewrite.sh — 357 lines of bash grep/sed
  • src/discover/registry.rs — 21 Rust regex patterns (for rtk discover)
  • Thomas's Gemini CLI hook — ~120 lines of bash grep/sed

Solution: rtk rewrite <cmd>

A new CLI command that acts as the canonical rewrite engine for all hook integrations.

# Simple command
$ rtk rewrite "git status"
rtk git status

# Compound command — each segment rewritten independently
$ rtk rewrite "git add . && cargo test"
rtk git add . && rtk cargo test

# Env prefix preserved
$ rtk rewrite "GIT_SSH_COMMAND=ssh git push"
GIT_SSH_COMMAND=ssh rtk git push

# Background operator
$ rtk rewrite "cargo build & cargo test"
rtk cargo build & rtk cargo test

# Pipe — only first segment rewritten (filter stays raw)
$ rtk rewrite "git log -10 | grep feat"
rtk git log -10 | grep feat

# No match — exits 1 (hook passes through unchanged)
$ rtk rewrite "terraform plan"
[exit 1]

Architecture

Before                          After
──────────────────────────────  ──────────────────────────────────
Claude hook   357-line bash  ─┐  Claude hook   18-line bash  ─┐
Gemini hook   120-line bash  ─┤  Gemini hook   15-line bash  ─┤  rtk rewrite "cmd"
Future hook   ???-line bash  ─┘  Any hook       ~15 lines    ─┘  (single source of truth)
                                                                  └── src/rewrite_cmd.rs
                                                                  └── src/discover/registry.rs

RTK is an oracle, not a shell. The hook still owns its LLM protocol (JSON format, stdin/stdout). RTK owns the rewrite logic. Each tool keeps its hook, RTK stays lean.

Changes

New: src/rewrite_cmd.rs (+387 lines)

  • rewrite_command(cmd) — main entry point, handles env prefixes
  • rewrite_segment(cmd) — single-command lookup against registry
  • rewrite_compound(cmd) — byte-level parser for &&, ||, ;, |, &
  • Word-boundary matching (prevents "rg" matching "rgsomething")
  • Heredoc detection (skips <<EOF patterns entirely)

Updated: .claude/hooks/rtk-rewrite.sh (357 → 55 lines, -85%)

Old hook: 357 lines of bash grep -qE + sed for every command.
New hook: delegates entirely to rtk rewrite.

REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { exit 0; }

No duplicate mapping logic. Adding a new RTK filter = zero hook changes.

Updated: src/discover/registry.rs

  • PATTERNS and RULES arrays now cover all commands including Python/Go/mypy
  • rewrite_command() / rewrite_compound() exposed from rewrite_cmd.rs
  • 27 command families covered: git, cargo, gh, pnpm, npm, npx, tsc, lint, prettier, next, vitest, playwright, prisma, docker, kubectl, curl, wget, mypy, ruff, pytest, pip, go, golangci-lint, read, grep, ls, find

New: CI validation (Documentation Validation workflow)

  • Verifies hook delegates to rtk rewrite (not bash grep/sed)
  • Verifies registry covers all Python/Go commands
  • Verifies module count consistency

What this enables

Thomas already adapted his Gemini hook to use rtk rewrite — 15 lines instead of 120. Any future LLM tool (Copilot, Codex MCP) gets the same integration in ~15 lines of bash.

Comparison with PR #156 (ahundt's Rust hook engine)

PR #156 PR #241 (this)
Lines added +7,342 +517
Approach RTK owns the entire hook lifecycle RTK is a rewrite oracle
Hook bash 4 lines (exec into RTK) 18 lines (calls rtk rewrite)
Multi-tool Each tool needs a new Rust handler Each tool writes ~15 lines of bash
JSON protocol Managed in Rust Managed in bash (tool owns its protocol)

PR #241 does 95% of the value in 7% of the code.

Testing

  • 455 unit tests pass (including 13 new rewrite_command tests)
  • All compound operators tested: &&, ||, ;, |, &
  • Edge cases: env prefixes, heredocs, already-RTK commands, unsupported commands
  • CI: Documentation Validation ✅, Security Check ✅, Benchmark Token Savings ✅

Rebased on master (0.23.0)

Includes all changes merged since the branch was created: mypy support (#109), fi fix (#246), docker compose fix (#245), musl CI target (#248), per-project rtk gain -p (#128).

Copilot AI review requested due to automatic review settings February 21, 2026 17:22
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces rtk rewrite, a new CLI command that serves as a centralized rewriting engine for LLM hooks (Claude Code, Gemini CLI). The goal is to eliminate duplicate command-mapping logic across multiple bash hooks by providing a single source of truth in Rust. When a new RTK filter is added, all hooks automatically support it without bash changes.

Changes:

  • Adds rtk rewrite <cmd> command that rewrites shell commands to their RTK equivalents (exits 0 with rewritten output, or exits 1 if unsupported)
  • Simplifies .claude/hooks/rtk-rewrite.sh from 357 lines to 60 lines by delegating rewrite logic to rtk rewrite
  • Extends src/discover/registry.rs with rewrite_prefixes field and rewrite functions that handle simple and compound commands (&&, ||, ;, |)

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/rewrite_cmd.rs New thin CLI wrapper that calls registry::rewrite_command and exits appropriately
src/main.rs Registers Commands::Rewrite variant with documentation
src/discover/registry.rs Adds rewrite_prefixes to RtkRule, implements rewrite_command/rewrite_compound/rewrite_segment/strip_word_prefix functions, adds 19 unit tests
.claude/hooks/rtk-rewrite.sh Simplified to single rtk rewrite call, removes 300+ lines of duplicate bash patterns
Comments suppressed due to low confidence (3)

src/discover/registry.rs:137

  • The rewrite_prefixes for "rtk read" include "head" and "tail", but this loses the special argument transformation that existed in the old bash hook.

The old hook converted:

  • head -10 file.txtrtk read file.txt --max-lines 10
  • head --lines=10 file.txtrtk read file.txt --max-lines 10

The new implementation would convert:

  • head -10 file.txtrtk read -10 file.txt (INVALID - rtk read doesn't understand -N syntax)

This means head/tail commands will be "rewritten" to invalid rtk read commands that will fail at execution time.

Consider either:

  1. Remove "head" and "tail" from rewrite_prefixes (let them pass through unchanged)
  2. Add logic to rewrite_segment to handle argument transformation for head/tail
  3. Update rtk read to accept head/tail-style arguments like -N

Option 1 is simplest and most consistent with the PR's philosophy of simple prefix replacement without complex transformations.

    RtkRule {
        rtk_cmd: "rtk read",
        rewrite_prefixes: &["cat", "head", "tail"],
        category: "Files",
        savings_pct: 60.0,
        subcmd_savings: &[],
        subcmd_status: &[],

src/discover/registry.rs:260

  • This PR removes support for several RTK commands that exist in the codebase but are missing from the RULES array. The following RTK commands have implementations (confirmed by src/ directory listing) but are not included in PATTERNS or RULES:
  • rtk pytest (src/pytest_cmd.rs exists)
  • rtk ruff (src/ruff_cmd.rs exists)
  • rtk pip (src/pip_cmd.rs exists)
  • rtk go (src/go_cmd.rs exists)
  • rtk golangci-lint (src/golangci_cmd.rs exists)

These commands were supported in the old bash hook (visible in the removed lines of .claude/hooks/rtk-rewrite.sh) but are now missing. This means users who were relying on these rewrites will experience a regression - their commands will no longer be automatically rewritten by LLM hooks.

To fix this, add entries to both PATTERNS and RULES arrays for each missing command, following the same pattern as existing entries. For example, for pytest:

  • Add pattern: r"^(python3?\s+-m\s+)?pytest(\s|$)"
  • Add rule with rewrite_prefixes: &["python -m pytest", "python3 -m pytest", "pytest"]
const PATTERNS: &[&str] = &[
    r"^git\s+(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)",
    r"^gh\s+(pr|issue|run|repo|api)",
    r"^cargo\s+(build|test|clippy|check|fmt)",
    r"^pnpm\s+(list|ls|outdated|install)",
    r"^npm\s+(run|exec)",
    r"^npx\s+",
    r"^(cat|head|tail)\s+",
    r"^(rg|grep)\s+",
    r"^ls(\s|$)",
    r"^find\s+",
    r"^(npx\s+|pnpm\s+)?tsc(\s|$)",
    r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)",
    r"^(npx\s+|pnpm\s+)?prettier",
    r"^(npx\s+|pnpm\s+)?next\s+build",
    r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)",
    r"^(npx\s+|pnpm\s+)?playwright",
    r"^(npx\s+|pnpm\s+)?prisma",
    r"^docker\s+(ps|images|logs)",
    r"^kubectl\s+(get|logs)",
    r"^curl\s+",
    r"^wget\s+",
];

const RULES: &[RtkRule] = &[
    RtkRule {
        rtk_cmd: "rtk git",
        rewrite_prefixes: &["git"],
        category: "Git",
        savings_pct: 70.0,
        subcmd_savings: &[
            ("diff", 80.0),
            ("show", 80.0),
            ("add", 59.0),
            ("commit", 59.0),
        ],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk gh",
        rewrite_prefixes: &["gh"],
        category: "GitHub",
        savings_pct: 82.0,
        subcmd_savings: &[("pr", 87.0), ("run", 82.0), ("issue", 80.0)],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk cargo",
        rewrite_prefixes: &["cargo"],
        category: "Cargo",
        savings_pct: 80.0,
        subcmd_savings: &[("test", 90.0), ("check", 80.0)],
        subcmd_status: &[("fmt", super::report::RtkStatus::Passthrough)],
    },
    RtkRule {
        rtk_cmd: "rtk pnpm",
        rewrite_prefixes: &["pnpm"],
        category: "PackageManager",
        savings_pct: 80.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk npm",
        rewrite_prefixes: &["npm"],
        category: "PackageManager",
        savings_pct: 70.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk npx",
        rewrite_prefixes: &["npx"],
        category: "PackageManager",
        savings_pct: 70.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk read",
        rewrite_prefixes: &["cat", "head", "tail"],
        category: "Files",
        savings_pct: 60.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk grep",
        rewrite_prefixes: &["rg", "grep"],
        category: "Files",
        savings_pct: 75.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk ls",
        rewrite_prefixes: &["ls"],
        category: "Files",
        savings_pct: 65.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk find",
        rewrite_prefixes: &["find"],
        category: "Files",
        savings_pct: 70.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        // Longest prefixes first for correct matching
        rtk_cmd: "rtk tsc",
        rewrite_prefixes: &["pnpm tsc", "npx tsc", "tsc"],
        category: "Build",
        savings_pct: 83.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk lint",
        rewrite_prefixes: &[
            "npx eslint",
            "pnpm lint",
            "npx biome",
            "eslint",
            "biome",
            "lint",
        ],
        category: "Build",
        savings_pct: 84.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk prettier",
        rewrite_prefixes: &["npx prettier", "pnpm prettier", "prettier"],
        category: "Build",
        savings_pct: 70.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        // "next build" is stripped to "rtk next" — the build subcommand is internal
        rtk_cmd: "rtk next",
        rewrite_prefixes: &["npx next build", "pnpm next build", "next build"],
        category: "Build",
        savings_pct: 87.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk vitest",
        rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"],
        category: "Tests",
        savings_pct: 99.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk playwright",
        rewrite_prefixes: &["npx playwright", "pnpm playwright", "playwright"],
        category: "Tests",
        savings_pct: 94.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk prisma",
        rewrite_prefixes: &["npx prisma", "pnpm prisma", "prisma"],
        category: "Build",
        savings_pct: 88.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk docker",
        rewrite_prefixes: &["docker"],
        category: "Infra",
        savings_pct: 85.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk kubectl",
        rewrite_prefixes: &["kubectl"],
        category: "Infra",
        savings_pct: 85.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk curl",
        rewrite_prefixes: &["curl"],
        category: "Network",
        savings_pct: 70.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
    RtkRule {
        rtk_cmd: "rtk wget",
        rewrite_prefixes: &["wget"],
        category: "Network",
        savings_pct: 65.0,
        subcmd_savings: &[],
        subcmd_status: &[],
    },
];

src/discover/registry.rs:210

  • The rewrite_prefixes array is incomplete - it doesn't cover all command forms that the PATTERN matches.

The PATTERN r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)" matches:

  • "pnpm test"
  • "npx test"
  • "test"
  • "npx jest"
  • "pnpm jest"

But rewrite_prefixes only includes: "pnpm vitest", "npx vitest", "vitest", "jest"

This means commands like "pnpm test" will match the pattern (classify as supported) but fail to rewrite because "pnpm test" isn't in the prefix list.

Additionally, the old bash hook converted "pnpm test" to "rtk vitest run" (with the "run" subcommand), but the current implementation would convert it to "rtk vitest" without a subcommand. Since VitestCommands requires a subcommand (line 745-752 of main.rs shows only a "Run" variant), "rtk vitest" without arguments would fail.

Add missing prefixes and consider whether "run" should be appended: rewrite_prefixes: &["pnpm vitest", "npx vitest", "pnpm test", "npx test", "vitest", "jest", "test"]

    RtkRule {
        rtk_cmd: "rtk vitest",
        rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"],
        category: "Tests",
        savings_pct: 99.0,
        subcmd_savings: &[],
        subcmd_status: &[],

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

rewrite_command("rtk git add . && cargo test"),
Some("rtk git add . && rtk cargo test".into())
);
}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for the || (OR) operator in compound commands. While the code handles || (lines 576-589), there's no test verifying this works correctly.

Add a test like:

#[test]
fn test_rewrite_compound_or() {
    assert_eq!(
        rewrite_command("git fetch || git pull"),
        Some("rtk git fetch || rtk git pull".into())
    );
}
Suggested change
}
}
#[test]
fn test_rewrite_compound_or() {
assert_eq!(
rewrite_command("git fetch || git pull"),
Some("rtk git fetch || rtk git pull".into())
);
}

Copilot uses AI. Check for mistakes.
rewrite_command("rtk git add . && cargo test"),
Some("rtk git add . && rtk cargo test".into())
);
}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for the ; (semicolon) separator in compound commands. While the code handles ; (lines 619-635), there's no test verifying this works correctly.

Add a test like:

#[test]
fn test_rewrite_compound_semicolon() {
    assert_eq!(
        rewrite_command("git add .; cargo test"),
        Some("rtk git add .; rtk cargo test".into())
    );
}
Suggested change
}
}
#[test]
fn test_rewrite_compound_semicolon() {
assert_eq!(
rewrite_command("git add .; cargo test"),
Some("rtk git add .; rtk cargo test".into())
);
}

Copilot uses AI. Check for mistakes.
Comment on lines 173 to +181
rtk_cmd: "rtk lint",
rewrite_prefixes: &[
"npx eslint",
"pnpm lint",
"npx biome",
"eslint",
"biome",
"lint",
],
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rewrite_prefixes array is incomplete - it doesn't cover all command forms that the PATTERN regex matches.

The PATTERN r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)" matches commands like:

  • "pnpm eslint"
  • "npx lint"
  • "pnpm biome"

But rewrite_prefixes only includes:

  • "npx eslint", "pnpm lint", "npx biome", "eslint", "biome", "lint"

Missing: "pnpm eslint", "npx lint", "pnpm biome check", "eslint check", "biome check" etc.

When a command like "pnpm eslint src/" is encountered:

  1. classify_command matches it (PATTERN matches)
  2. rewrite_segment tries all prefixes but none match "pnpm eslint"
  3. Returns None - rewrite silently fails despite command being "supported"

Add missing prefix combinations to ensure all matched patterns can be rewritten:
rewrite_prefixes: &["npx eslint", "pnpm eslint", "npx biome", "pnpm biome", "pnpm lint", "npx lint", "eslint", "biome", "lint"]

Copilot uses AI. Check for mistakes.
Comment on lines 37 to 54
*'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;;
esac

# Strip leading env var assignments for pattern matching
# e.g., "TEST_SESSION_ID=2 npx playwright test" → match against "npx playwright test"
# but preserve them in the rewritten command for execution.
ENV_PREFIX=$(echo "$FIRST_CMD" | grep -oE '^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+' || echo "")
if [ -n "$ENV_PREFIX" ]; then
MATCH_CMD="${FIRST_CMD:${#ENV_PREFIX}}"
CMD_BODY="${CMD:${#ENV_PREFIX}}"
else
MATCH_CMD="$FIRST_CMD"
CMD_BODY="$CMD"
fi

REWRITTEN=""

# --- Git commands ---
if echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+status([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git status/rtk git status/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+diff([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git diff/rtk git diff/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+log([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git log/rtk git log/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+add([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git add/rtk git add/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+commit([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git commit/rtk git commit/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+push([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git push/rtk git push/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+pull([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git pull/rtk git pull/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+branch([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git branch/rtk git branch/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+fetch([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git fetch/rtk git fetch/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+stash([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git stash/rtk git stash/')"
elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+show([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git show/rtk git show/')"

# --- GitHub CLI (added: api, release) ---
elif echo "$MATCH_CMD" | grep -qE '^gh[[:space:]]+(pr|issue|run|api|release)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^gh /rtk gh /')"

# --- Cargo ---
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+test([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo test/rtk cargo test/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+build([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo build/rtk cargo build/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+clippy([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo clippy/rtk cargo clippy/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+check([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo check/rtk cargo check/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+install([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo install/rtk cargo install/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+nextest([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo nextest/rtk cargo nextest/')"
elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+fmt([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo fmt/rtk cargo fmt/')"

# --- File operations ---
elif echo "$MATCH_CMD" | grep -qE '^cat[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cat /rtk read /')"
elif echo "$MATCH_CMD" | grep -qE '^(rg|grep)[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(rg|grep) /rtk grep /')"
elif echo "$MATCH_CMD" | grep -qE '^ls([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ls/rtk ls/')"
elif echo "$MATCH_CMD" | grep -qE '^tree([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^tree/rtk tree/')"
elif echo "$MATCH_CMD" | grep -qE '^find[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^find /rtk find /')"
elif echo "$MATCH_CMD" | grep -qE '^diff[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^diff /rtk diff /')"
elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+'; then
# Transform: head -N file → rtk read file --max-lines N
# Also handle: head --lines=N file
if echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+-[0-9]+[[:space:]]+'; then
LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +-([0-9]+) +.+$/\1/')
FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +-[0-9]+ +(.+)$/\1/')
REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES"
elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+--lines=[0-9]+[[:space:]]+'; then
LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=([0-9]+) +.+$/\1/')
FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=[0-9]+ +(.+)$/\1/')
REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES"
fi

# --- JS/TS tooling (added: npm run, npm test, vue-tsc) ---
elif echo "$MATCH_CMD" | grep -qE '^(pnpm[[:space:]]+)?(npx[[:space:]]+)?vitest([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(pnpm )?(npx )?vitest( run)?/rtk vitest run/')"
elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+test([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm test/rtk vitest run/')"
elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+test([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm test/rtk npm test/')"
elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+run[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm run /rtk npm /')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?vue-tsc([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?vue-tsc/rtk tsc/')"
elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+tsc([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm tsc/rtk tsc/')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?tsc([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?tsc/rtk tsc/')"
elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+lint([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm lint/rtk lint/')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?eslint([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?eslint/rtk lint/')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prettier([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prettier/rtk prettier/')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?playwright([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?playwright/rtk playwright/')"
elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+playwright([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm playwright/rtk playwright/')"
elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prisma([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prisma/rtk prisma/')"

# --- Containers (added: docker compose, docker run/build/exec, kubectl describe/apply) ---
elif echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]+compose([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')"
elif echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]+(ps|images|logs|run|build|exec)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')"
elif echo "$MATCH_CMD" | grep -qE '^kubectl[[:space:]]+(get|logs|describe|apply)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^kubectl /rtk kubectl /')"

# --- Network ---
elif echo "$MATCH_CMD" | grep -qE '^curl[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^curl /rtk curl /')"
elif echo "$MATCH_CMD" | grep -qE '^wget[[:space:]]+'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^wget /rtk wget /')"

# --- pnpm package management ---
elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+(list|ls|outdated)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm /rtk pnpm /')"

# --- Python tooling ---
elif echo "$MATCH_CMD" | grep -qE '^pytest([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pytest/rtk pytest/')"
elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+pytest([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m pytest/rtk pytest/')"
elif echo "$MATCH_CMD" | grep -qE '^ruff[[:space:]]+(check|format)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ruff /rtk ruff /')"
elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')"
elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')"

# --- Go tooling ---
elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go test/rtk go test/')"
elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+build([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go build/rtk go build/')"
elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')"
elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then
REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')"
fi

# If no rewrite needed, approve as-is
if [ -z "$REWRITTEN" ]; then
# Rewrite via rtk — single source of truth for all command mappings.
# Exit 1 = no RTK equivalent, pass through unchanged.
# Exit 0 = rewritten command (or already RTK, identical output).
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || {
_rtk_audit_log "skip:no_match" "$CMD"
exit 0
}

# If output is identical, command was already using RTK — nothing to do.
if [ "$CMD" = "$REWRITTEN" ]; then
_rtk_audit_log "skip:already_rtk" "$CMD"
exit 0
fi

_rtk_audit_log "rewrite" "$CMD" "$REWRITTEN"
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calls to _rtk_audit_log with "$CMD" and "$REWRITTEN" persist the full command line and its rewritten form to a local log file, which can include secrets such as API tokens, passwords in URLs, or authorization headers. An attacker (or another local user, backups, or support channels receiving this log) could recover those secrets from ${HOME}/.local/share/rtk/hook-audit.log when RTK_HOOK_AUDIT=1. To reduce this risk, avoid logging raw command strings, or at least redact/strip sensitive arguments and ensure the log file has appropriately restricted permissions and a clear warning in documentation about potential secret exposure.

Copilot uses AI. Check for mistakes.
FlorianBruniaux added a commit that referenced this pull request Feb 22, 2026
Since PR #241, the hook delegates to `rtk rewrite` — command mappings
live in src/discover/registry.rs, not the bash hook script.

Update the "Verify hook coverage" CI step to:
- Check that the hook calls `rtk rewrite` (new architecture)
- Check that registry.rs has rewrite_prefixes for all Python/Go commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FlorianBruniaux and others added 5 commits March 2, 2026 09:10
…rites

Implements `rtk rewrite <cmd>` as the canonical rewrite engine for all
LLM hook integrations (Claude Code, Gemini CLI, future tools).

- Add `rewrite_prefixes` field to `RtkRule` in discover/registry.rs
- Add public `rewrite_command()` with compound command support (&&, ||, ;, |)
- Add `rewrite_segment()`, `rewrite_compound()`, `strip_word_prefix()` helpers
- Handle already-rtk commands (exit 0, identical output)
- Handle unsupported/ignored commands (exit 1, no output)
- Add 20 unit tests covering all branches and edge cases
- Create `src/rewrite_cmd.rs` thin CLI wrapper
- Register `Commands::Rewrite` in main.rs
- Simplify `.claude/hooks/rtk-rewrite.sh` from 357 → 60 lines

Hooks no longer need duplicate mapping logic — a single
`REWRITTEN=$(rtk rewrite "$CMD") || exit 0` handles everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md, CLAUDE.md, ARCHITECTURE.md: 0.20.1 → 0.22.2
- ARCHITECTURE.md: module count 48 → 51 (added rewrite_cmd + 2 from master)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Since PR #241, the hook delegates to `rtk rewrite` — command mappings
live in src/discover/registry.rs, not the bash hook script.

Update the "Verify hook coverage" CI step to:
- Check that the hook calls `rtk rewrite` (new architecture)
- Check that registry.rs has rewrite_prefixes for all Python/Go commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per feedback from @xDelph: AI agents increasingly use `cmd1 & cmd2`
for parallel execution. This commit adds support alongside existing
`&&`, `||`, `;`, and `|` operators.

Changes:
- rewrite_compound: add match arm for single `&` (after `&&` check)
- rewrite_command: add `" & "` to has_compound detection
- init: show "installed/updated" vs "already up to date" so users
  know whether rtk init changed the hook on re-run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `ruff`, `pytest`, `pip`, `go`, and `golangci-lint` to both
PATTERNS and RULES in registry.rs so the CI coverage check passes
and `rtk rewrite` correctly identifies these commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FlorianBruniaux and others added 3 commits March 2, 2026 09:12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pszymkowiak
Copy link
Collaborator

Thanks for this PR — rtk rewrite as a single source of truth is exactly the right direction, and the Rust implementation of rewrite_command() is solid.

Three P0 bugs to fix before merge:

P0.1 — rtk init still installs the old hook
src/init.rs embeds hooks/rtk-rewrite.sh (218 lines, all bash logic) — not the new thin delegating hook. Users running rtk init after this PR never get the new hook. Fix: update
hooks/rtk-rewrite.sh to be the thin delegating hook.

P0.2 — head -20 file crashes at runtime
head -20 src/main.rs → rewritten to rtk read -20 src/main.rs → clap error. Fix: skip rewriting head when numeric flags are present, or translate head -N file → rtk read file --max-lines
N.

P0.3 — Silent failure with rtk < 0.23.0
If the user has an older rtk, rtk rewrite doesn't exist → hook does || exit 0 → zero rewrites, zero errors. Add a version guard or an explicit fallback message.

Also missing from the registry vs old hook: gh release, kubectl describe/apply, docker run/exec/build, cargo install, tree, diff.

FlorianBruniaux and others added 2 commits March 2, 2026 11:07
P0.1 - Replace 218-line bash hook with 58-line thin delegating hook.
  All rewrite logic now lives exclusively in `rtk rewrite` (Rust registry).
  `rtk init` installs the thin hook via include_str!.

P0.2 - Fix `head -20 file` crash at runtime.
  Generic prefix replacement produced `rtk read -20 file` (invalid clap args).
  Now translates `head -N file` → `rtk read file --max-lines N` and skips
  unsupported head flags (e.g. -c) by returning exit 1.

P0.3 - Add version guard in hook for rtk < 0.23.0.
  Prints a warning to stderr instead of silently doing nothing.

Also adds missing registry entries vs old hook:
  - gh release, cargo install
  - docker run/exec/build, kubectl describe/apply
  - tree, diff

474 tests pass, 0 clippy warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@zeval
Copy link

zeval commented Mar 2, 2026

This makes a lot of sense, and given this I'll be rewriting #300 to build on this instead of adding another hook implementaiton.

zeval added a commit to zeval/rtk that referenced this pull request Mar 2, 2026
Now that PR rtk-ai#241 centralizes rewrite logic in `rtk rewrite`, simplify
the OpenCode plugin from 214 lines of duplicated TS rewrite rules to a
38-line thin wrapper that shells out to `rtk rewrite`.

Also remove the bash/TS parity test from init.rs — no longer needed
since both hooks delegate to the same Rust function.

-355 lines removed, +18 added.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really big file. RTK will become harder and harder to maintain because of those big files that will cause trouble with contributions.

If possible, we prefer storing those constant variables in respective files in a specific folder.

(eg: rewrite/constants/patterns.rs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants